v{{ swh_web_version }}
v{{ swh_web_version|split:"+"|first }}
diff --git a/assets/src/bundles/admin/index.js b/assets/src/bundles/admin/index.js index 6910bd3a..63f26cdc 100644 --- a/assets/src/bundles/admin/index.js +++ b/assets/src/bundles/admin/index.js @@ -1,9 +1,10 @@ /** - * Copyright (C) 2018 The Software Heritage developers + * Copyright (C) 2018-2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ export * from './deposit'; +export * from './mailmap'; export * from './origin-save'; diff --git a/assets/src/bundles/admin/mailmap-form.ejs b/assets/src/bundles/admin/mailmap-form.ejs new file mode 100644 index 00000000..a20174f8 --- /dev/null +++ b/assets/src/bundles/admin/mailmap-form.ejs @@ -0,0 +1,29 @@ +<%# + Copyright (C) 2022 The Software Heritage developers + See the AUTHORS file at the top-level directory of this distribution + License: GNU Affero General Public License version 3, or any later version + See top-level LICENSE file for more information +%> + +
\ No newline at end of file diff --git a/assets/src/bundles/admin/mailmap.js b/assets/src/bundles/admin/mailmap.js new file mode 100644 index 00000000..25365dcb --- /dev/null +++ b/assets/src/bundles/admin/mailmap.js @@ -0,0 +1,159 @@ +/** + * Copyright (C) 2022 The Software Heritage developers + * See the AUTHORS file at the top-level directory of this distribution + * License: GNU Affero General Public License version 3, or any later version + * See top-level LICENSE file for more information + */ + +import {csrfPost, handleFetchError} from 'utils/functions'; + +import mailmapFormTemplate from './mailmap-form.ejs'; + +let mailmapsTable; + +export function mailmapForm(buttonText, email = '', displayName = '', + displayNameActivated = false, update = false) { + return mailmapFormTemplate({ + buttonText: buttonText, + email: email, + displayName: displayName, + displayNameActivated: displayNameActivated, + updateForm: update + }); +} + +function getMailmapDataFromForm() { + return { + 'from_email': $('#swh-mailmap-from-email').val(), + 'display_name': $('#swh-mailmap-display-name').val(), + 'display_name_activated': $('#swh-mailmap-display-name-activated').prop('checked') + }; +} + +function processMailmapForm(formTitle, formHtml, formApiUrl) { + swh.webapp.showModalHtml(formTitle, formHtml); + $(`#swh-mailmap-form`).on('submit', async event => { + event.preventDefault(); + event.stopPropagation(); + const postData = getMailmapDataFromForm(); + try { + const response = await csrfPost( + formApiUrl, {'Content-Type': 'application/json'}, JSON.stringify(postData) + ); + $('#swh-web-modal-html').modal('hide'); + handleFetchError(response); + mailmapsTable.draw(); + } catch (response) { + const error = await response.text(); + swh.webapp.showModalMessage('Error', error); + } + }); +} + +export function addNewMailmap() { + const mailmapFormHtml = mailmapForm('Add mailmap'); + processMailmapForm('Add new mailmap', mailmapFormHtml, Urls.profile_mailmap_add()); +} + +export function updateMailmap(mailmapId) { + let mailmapData; + const rows = mailmapsTable.rows().data(); + for (let i = 0; i < rows.length; ++i) { + const row = rows[i]; + if (row.id === mailmapId) { + mailmapData = row; + break; + } + } + const mailmapFormHtml = mailmapForm('Update mailmap', mailmapData.from_email, + mailmapData.display_name, + mailmapData.display_name_activated, true); + processMailmapForm('Update existing mailmap', mailmapFormHtml, Urls.profile_mailmap_update()); +} + +const mdiCheckBold = ''; +const mdiCloseThick = ''; + +export function initMailmapUI() { + $(document).ready(() => { + mailmapsTable = $('#swh-mailmaps-table') + .on('error.dt', (e, settings, techNote, message) => { + $('#swh-mailmaps-list-error').text( + 'An error occurred while retrieving the mailmaps list'); + console.log(message); + }) + .DataTable({ + serverSide: true, + ajax: Urls.profile_mailmap_list_datatables(), + columns: [ + { + data: 'from_email', + name: 'from_email', + render: $.fn.dataTable.render.text() + }, + { + data: 'from_email_verified', + name: 'from_email_verified', + render: (data, type, row) => { + return data ? mdiCheckBold : mdiCloseThick; + }, + className: 'dt-center' + }, + { + data: 'display_name', + name: 'display_name', + render: $.fn.dataTable.render.text() + }, + { + data: 'display_name_activated', + name: 'display_name_activated', + render: (data, type, row) => { + return data ? mdiCheckBold : mdiCloseThick; + }, + className: 'dt-center' + }, + { + data: 'last_update_date', + name: 'last_update_date', + render: (data, type, row) => { + if (type === 'display') { + const date = new Date(data); + return date.toLocaleString(); + } + return data; + } + }, + { + render: (data, type, row) => { + const lastUpdateDate = new Date(row.last_update_date); + const lastProcessingDate = new Date(row.mailmap_last_processing_date); + if (!lastProcessingDate || lastProcessingDate < lastUpdateDate) { + return mdiCloseThick; + } else { + return mdiCheckBold; + } + }, + className: 'dt-center', + orderable: false + }, + { + render: (data, type, row) => { + const html = + ``; + return html; + }, + orderable: false + } + + ], + ordering: true, + searching: true, + searchDelay: 1000, + scrollY: '50vh', + scrollCollapse: true + }); + }); +} diff --git a/cypress/integration/mailmap.spec.js b/cypress/integration/mailmap.spec.js new file mode 100644 index 00000000..d9ee1fde --- /dev/null +++ b/cypress/integration/mailmap.spec.js @@ -0,0 +1,226 @@ +/** + * Copyright (C) 2022 The Software Heritage developers + * See the AUTHORS file at the top-level directory of this distribution + * License: GNU Affero General Public License version 3, or any later version + * See top-level LICENSE file for more information + */ + +const $ = Cypress.$; + +function fillFormAndSubmitMailmap(fromEmail, displayName, activated) { + if (fromEmail) { + cy.get('#swh-mailmap-from-email') + .clear() + .type(fromEmail, {delay: 0, force: true}); + } + + if (displayName) { + cy.get('#swh-mailmap-display-name') + .clear() + .type(displayName, {delay: 0, force: true}); + } + + if (activated) { + cy.get('#swh-mailmap-display-name-activated') + .check({force: true}); + } else { + cy.get('#swh-mailmap-display-name-activated') + .uncheck({force: true}); + } + + cy.get('#swh-mailmap-form-submit') + .click(); +} + +function addNewMailmap(fromEmail, displayName, activated) { + cy.get('#swh-add-new-mailmap') + .click(); + + fillFormAndSubmitMailmap(fromEmail, displayName, activated); +} + +function updateMailmap(fromEmail, displayName, activated) { + cy.contains('Edit') + .click(); + + fillFormAndSubmitMailmap(fromEmail, displayName, activated); +} + +function checkMailmapRow(fromEmail, displayName, activated, + processed = false, row = 1, nbRows = 1) { + cy.get('tbody tr').then(rows => { + assert.equal(rows.length, 1); + const cells = rows[0].cells; + assert.equal($(cells[0]).text(), fromEmail); + assert.include($(cells[1]).html(), 'mdi-check-bold'); + assert.equal($(cells[2]).text(), displayName); + assert.include($(cells[3]).html(), activated ? 'mdi-check-bold' : 'mdi-close-thick'); + assert.notEqual($(cells[4]).text(), ''); + assert.include($(cells[5]).html(), processed ? 'mdi-check-bold' : 'mdi-close-thick'); + }); +} + +describe('Test mailmap administration', function() { + + before(function() { + this.url = this.Urls.admin_mailmap(); + }); + + beforeEach(function() { + cy.task('db:user_mailmap:delete'); + cy.intercept('POST', this.Urls.profile_mailmap_add()) + .as('mailmapAdd'); + cy.intercept('POST', this.Urls.profile_mailmap_update()) + .as('mailmapUpdate'); + cy.intercept(`${this.Urls.profile_mailmap_list_datatables()}**`) + .as('mailmapList'); + }); + + it('should not display mailmap admin link in sidebar when anonymous', function() { + cy.visit(this.url); + cy.get('.swh-mailmap-admin-item') + .should('not.exist'); + }); + + it('should not display mailmap admin link when connected as unprivileged user', function() { + cy.userLogin(); + cy.visit(this.url); + + cy.get('.swh-mailmap-admin-item') + .should('not.exist'); + + }); + + it('should display mailmap admin link in sidebar when connected as privileged user', function() { + cy.mailmapAdminLogin(); + cy.visit(this.url); + + cy.get('.swh-mailmap-admin-item') + .should('exist'); + }); + + it('should not create a new mailmap when input data are empty', function() { + cy.mailmapAdminLogin(); + cy.visit(this.url); + + addNewMailmap('', '', true); + + cy.get('#swh-mailmap-form :invalid').should('exist'); + + cy.get('#swh-mailmap-form') + .should('be.visible'); + + }); + + it('should not create a new mailmap when from email is invalid', function() { + cy.mailmapAdminLogin(); + cy.visit(this.url); + + addNewMailmap('invalid_email', 'display name', true); + + cy.get('#swh-mailmap-form :invalid').should('exist'); + + cy.get('#swh-mailmap-form') + .should('be.visible'); + }); + + it('should create a new mailmap when input data are valid', function() { + cy.mailmapAdminLogin(); + cy.visit(this.url); + + const fromEmail = 'user@example.org'; + const displayName = 'New user display name'; + addNewMailmap(fromEmail, displayName, true); + cy.wait('@mailmapAdd'); + + cy.get('#swh-mailmap-form :invalid').should('not.exist'); + + // ensure table redraw before next check + cy.contains(fromEmail); + + cy.get('#swh-mailmap-form') + .should('not.be.visible'); + + checkMailmapRow(fromEmail, displayName, true); + + }); + + it('should not create a new mailmap for an email already mapped', function() { + cy.mailmapAdminLogin(); + cy.visit(this.url); + + const fromEmail = 'user@example.org'; + const displayName = 'New user display name'; + addNewMailmap(fromEmail, displayName, true); + cy.wait('@mailmapAdd'); + + addNewMailmap(fromEmail, displayName, true); + cy.wait('@mailmapAdd'); + + cy.get('#swh-mailmap-form') + .should('not.be.visible'); + + cy.contains('Error') + .should('be.visible'); + + checkMailmapRow(fromEmail, displayName, true); + + }); + + it('should update a mailmap', function() { + cy.mailmapAdminLogin(); + cy.visit(this.url); + + const fromEmail = 'user@example.org'; + const displayName = 'New display name'; + addNewMailmap(fromEmail, displayName, false); + cy.wait('@mailmapAdd'); + + cy.get('#swh-mailmap-form :invalid').should('not.exist'); + + // ensure table redraw before next check + cy.contains(fromEmail); + + cy.get('#swh-mailmap-form') + .should('not.be.visible'); + + checkMailmapRow(fromEmail, displayName, false); + + const newDisplayName = 'Updated display name'; + updateMailmap('', newDisplayName, true); + cy.wait('@mailmapUpdate'); + + cy.get('#swh-mailmap-form :invalid').should('not.exist'); + + // ensure table redraw before next check + cy.contains(fromEmail); + + cy.get('#swh-mailmap-form') + .should('not.be.visible'); + + checkMailmapRow(fromEmail, newDisplayName, true); + + }); + + it('should indicate when a mailmap has been processed', function() { + cy.mailmapAdminLogin(); + cy.visit(this.url); + + const fromEmail = 'user@example.org'; + const displayName = 'New user display name'; + addNewMailmap(fromEmail, displayName, true); + cy.wait('@mailmapAdd'); + + // ensure table redraw before next check + cy.contains(fromEmail); + + checkMailmapRow(fromEmail, displayName, true, false); + + cy.task('db:user_mailmap:mark_processed'); + + cy.visit(this.url); + checkMailmapRow(fromEmail, displayName, true, true); + + }); + +}); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index 7cd36c78..01245fcc 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -1,130 +1,152 @@ /** * Copyright (C) 2019-2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ const axios = require('axios'); const fs = require('fs'); +const sqlite3 = require('sqlite3').verbose(); async function httpGet(url) { const response = await axios.get(url); return response.data; } async function getMetadataForOrigin(originUrl, baseUrl) { const originVisitsApiUrl = `${baseUrl}/api/1/origin/${originUrl}/visits`; const originVisits = await httpGet(originVisitsApiUrl); const lastVisit = originVisits[0]; const snapshotApiUrl = `${baseUrl}/api/1/snapshot/${lastVisit.snapshot}`; const lastOriginSnapshot = await httpGet(snapshotApiUrl); let revision = lastOriginSnapshot.branches.HEAD.target; if (lastOriginSnapshot.branches.HEAD.target_type === 'alias') { revision = lastOriginSnapshot.branches[revision].target; } const revisionApiUrl = `${baseUrl}/api/1/revision/${revision}`; const lastOriginHeadRevision = await httpGet(revisionApiUrl); return { 'directory': lastOriginHeadRevision.directory, 'revision': lastOriginHeadRevision.id, 'snapshot': lastOriginSnapshot.id }; }; +function getDatabase() { + return new sqlite3.Database('./swh-web-test.sqlite3'); +} + module.exports = (on, config) => { require('@cypress/code-coverage/task')(on, config); // produce JSON files prior launching browser in order to dynamically generate tests on('before:browser:launch', function(browser, launchOptions) { return new Promise((resolve) => { const p1 = axios.get(`${config.baseUrl}/tests/data/content/code/extensions/`); const p2 = axios.get(`${config.baseUrl}/tests/data/content/code/filenames/`); Promise.all([p1, p2]) .then(function(responses) { fs.writeFileSync('cypress/fixtures/source-file-extensions.json', JSON.stringify(responses[0].data)); fs.writeFileSync('cypress/fixtures/source-file-names.json', JSON.stringify(responses[1].data)); resolve(); }); }); }); on('task', { getSwhTestsData: async() => { if (!global.swhTestsData) { const swhTestsData = {}; swhTestsData.unarchivedRepo = { url: 'https://github.com/SoftwareHeritage/swh-web', type: 'git', revision: '7bf1b2f489f16253527807baead7957ca9e8adde', snapshot: 'd9829223095de4bb529790de8ba4e4813e38672d', rootDirectory: '7d887d96c0047a77e2e8c4ee9bb1528463677663', content: [{ sha1git: 'b203ec39300e5b7e97b6e20986183cbd0b797859' }] }; swhTestsData.origin = [{ url: 'https://github.com/memononen/libtess2', type: 'git', content: [{ path: 'Source/tess.h' }, { path: 'premake4.lua' }], directory: [{ path: 'Source', id: 'cd19126d815470b28919d64b2a8e6a3e37f900dd' }], revisions: [], invalidSubDir: 'Source1' }, { url: 'https://github.com/wcoder/highlightjs-line-numbers.js', type: 'git', content: [{ path: 'src/highlightjs-line-numbers.js' }], directory: [], revisions: ['1c480a4573d2a003fc2630c21c2b25829de49972'], release: { name: 'v2.6.0', id: '6877028d6e5412780517d0bfa81f07f6c51abb41', directory: '5b61d50ef35ca9a4618a3572bde947b8cccf71ad' } }]; for (const origin of swhTestsData.origin) { const metadata = await getMetadataForOrigin(origin.url, config.baseUrl); const directoryApiUrl = `${config.baseUrl}/api/1/directory/${metadata.directory}`; origin.dirContent = await httpGet(directoryApiUrl); origin.rootDirectory = metadata.directory; origin.revisions.push(metadata.revision); origin.snapshot = metadata.snapshot; for (const content of origin.content) { const contentPathApiUrl = `${config.baseUrl}/api/1/directory/${origin.rootDirectory}/${content.path}`; const contentMetaData = await httpGet(contentPathApiUrl); content.name = contentMetaData.name.split('/').slice(-1)[0]; content.sha1git = contentMetaData.target; content.directory = contentMetaData.dir_id; const rawFileUrl = `${config.baseUrl}/browse/content/sha1_git:${content.sha1git}/raw/?filename=${content.name}`; const fileText = await httpGet(rawFileUrl); const fileLines = fileText.split('\n'); content.numberLines = fileLines.length; if (!fileLines[content.numberLines - 1]) { // If last line is empty its not shown content.numberLines -= 1; } } } global.swhTestsData = swhTestsData; } return global.swhTestsData; + }, + 'db:user_mailmap:delete': () => { + const db = getDatabase(); + db.serialize(function() { + db.run('DELETE FROM user_mailmap'); + db.run('DELETE FROM user_mailmap_event'); + }); + db.close(); + return true; + }, + 'db:user_mailmap:mark_processed': () => { + const db = getDatabase(); + db.serialize(function() { + db.run('UPDATE user_mailmap SET mailmap_last_processing_date=datetime("now", "+1 hour")'); + }); + db.close(); + return true; } }); return config; }; diff --git a/cypress/support/index.js b/cypress/support/index.js index 15a3ad85..1c09614d 100644 --- a/cypress/support/index.js +++ b/cypress/support/index.js @@ -1,103 +1,106 @@ /** * Copyright (C) 2019-2022 The Software Heritage developers * See the AUTHORS file at the top-level directory of this distribution * License: GNU Affero General Public License version 3, or any later version * See top-level LICENSE file for more information */ import 'cypress-hmr-restarter'; import '@cypress/code-coverage/support'; Cypress.Screenshot.defaults({ screenshotOnRunFailure: false }); Cypress.Commands.add('xhrShouldBeCalled', (alias, timesCalled) => { const testRoutes = cy.state('routes'); const aliasRoute = Cypress._.find(testRoutes, {alias}); expect(Object.keys(aliasRoute.requests || {})).to.have.length(timesCalled); }); function loginUser(username, password) { const url = '/admin/login/'; return cy.request({ url: url, method: 'GET' }).then(() => { cy.getCookie('sessionid').should('not.exist'); cy.getCookie('csrftoken').its('value').then((token) => { cy.request({ url: url, method: 'POST', form: true, followRedirect: false, body: { username: username, password: password, csrfmiddlewaretoken: token } }).then(() => { cy.getCookie('sessionid').should('exist'); return cy.getCookie('csrftoken').its('value'); }); }); }); } Cypress.Commands.add('adminLogin', () => { return loginUser('admin', 'admin'); }); Cypress.Commands.add('userLogin', () => { return loginUser('user', 'user'); }); Cypress.Commands.add('ambassadorLogin', () => { return loginUser('ambassador', 'ambassador'); }); Cypress.Commands.add('depositLogin', () => { return loginUser('deposit', 'deposit'); }); Cypress.Commands.add('addForgeModeratorLogin', () => { return loginUser('add-forge-moderator', 'add-forge-moderator'); }); function mockCostlyRequests() { cy.intercept('https://status.softwareheritage.org/**', { body: { 'result': { 'status': [ { 'id': '5f7c4c567f50b304c1e7bd5f', 'name': 'Save Code Now', 'updated': '2020-11-30T13:51:21.151Z', 'status': 'Operational', 'status_code': 100 } ] } }}).as('swhPlatformStatus'); cy.intercept('/coverage', { body: '' }).as('swhCoverageWidget'); } +Cypress.Commands.add('mailmapAdminLogin', () => { + return loginUser('mailmap-admin', 'mailmap-admin'); +}); before(function() { mockCostlyRequests(); cy.task('getSwhTestsData').then(testsData => { Object.assign(this, testsData); }); cy.visit('/').window().then(async win => { this.Urls = win.Urls; }); }); beforeEach(function() { mockCostlyRequests(); }); diff --git a/package.json b/package.json index 71a8c2d6..3ba45681 100644 --- a/package.json +++ b/package.json @@ -1,177 +1,178 @@ { "name": "swh-web", "version": "0.0.317", "description": "Static assets management for swh-web", "scripts": { "build-dev": "NODE_ENV=development webpack --config assets/config/webpack.config.development.js --color", "build-test": "NODE_ENV=test webpack --config assets/config/webpack.config.development.js --color", "start-dev": "NODE_ENV=development nodemon --watch swh/web/api --watch swh/web/browse --watch swh/web/templates --watch swh/web/common --watch swh/web/settings --watch assets/config --ext py,html,js --exec \"webpack serve --config assets/config/webpack.config.development.js --color\"", "build": "NODE_ENV=production webpack --config assets/config/webpack.config.production.js --color", "mochawesome": "mochawesome-merge cypress/mochawesome/results/*.json > cypress/mochawesome/mochawesome.json && marge -o cypress/mochawesome/report cypress/mochawesome/mochawesome.json", "eslint": "eslint -c assets/config/.eslintrc --fix assets/** cypress/integration/** cypress/plugins/** cypress/support/**", "preinstall": "npm -v || (SWH_WEB=$PWD && cd /tmp && yarn add npm && cd node_modules/npm && yarn link && cd $SWH_WEB && yarn link npm)", "nyc-report": "nyc report --reporter=lcov" }, "repository": { "type": "git", "url": "https://forge.softwareheritage.org/source/swh-web" }, "author": "The Software Heritage developers", "license": "AGPL-3.0-or-later", "dependencies": { "@babel/runtime-corejs3": "^7.17.2", "@mdi/font": "^6.5.95", "@sentry/browser": "^6.18.2", "admin-lte": "^3.2.0", "ansi_up": "^5.1.0", "bootstrap": "^4.6.1", "chosen-js": "^1.8.7", "clipboard": "^2.0.10", "core-js": "^3.21.1", "d3": "^7.3.0", "datatables.net": "^1.11.5", "datatables.net-bs4": "^1.11.5", "dompurify": "^2.3.6", "email-validator": "^2.0.4", "hex-rgb": "^5.0.0", "highlight.js": "^11.5.0", "highlightjs-4d": "^1.0.6", "highlightjs-alan": "^0.0.2", "highlightjs-blade": "^0.1.0", "highlightjs-chaos": "^0.0.10", "highlightjs-chapel": "github:chapel-lang/highlightjs-chapel", "highlightjs-cpcdos": "github:SPinti-Software/highlightjs-cpcdos", "highlightjs-cshtml-razor": "^2.1.1", "highlightjs-cypher": "^1.1.1", "highlightjs-dafny": "github:ConsenSys/highlightjs-dafny", "highlightjs-dylan": "github:highlightjs/highlightjs-dylan", "highlightjs-eta": "^0.1.0", "highlightjs-extempore": "github:highlightjs/highlightjs-extempore", "highlightjs-gdscript": "github:highlightjs/highlightjs-gdscript", "highlightjs-gf": "^1.0.1", "highlightjs-gsql": "github:TigerGraph-DevLabs/highlightjs-gsql", "highlightjs-hlsl": "github:highlightjs/highlightjs-hlsl", "highlightjs-jolie": "^0.1.8", "highlightjs-lean": "^1.1.0", "highlightjs-line-numbers.js": "^2.8.0", "highlightjs-lox": "^2.0.6", "highlightjs-mirc": "github:highlightjs/highlightjs-mirc", "highlightjs-modelica": "^1.0.0", "highlightjs-never": "github:never-lang/highlightjs-never", "highlightjs-octave": "^0.1.0", "highlightjs-oz": "^0.0.3", "highlightjs-qsharp": "^1.0.2", "highlightjs-redbol": "^1.0.3", "highlightjs-robot": "github:highlightjs/highlightjs-robot", "highlightjs-robots-txt": "^0.9.1", "highlightjs-rpm-specfile": "^1.0.0", "highlightjs-sap-abap": "^0.2.0", "highlightjs-solidity": "^2.0.4", "highlightjs-svelte": "^1.0.6", "highlightjs-terraform": "github:highlightjs/highlightjs-terraform", "highlightjs-xsharp": "^1.0.0", "highlightjs-zenscript": "^2.0.0", "highlightjs-zig": "^1.0.2", "hightlightjs-papyrus": "github:Pickysaurus/highlightjs-papyrus", "html-encoder-decoder": "^1.3.9", "iframe-resizer": "^4.3.2", "intro.js": "^5.0.0", "jquery": "^3.6.0", "js-cookie": "^3.0.1", "js-year-calendar": "^2.0.0", "mathjax": "^3.2.0", "notebookjs": "^0.6.7", "object-fit-images": "^3.2.4", "org": "^0.2.0", "pdfjs-dist": "^2.13.216", "popper.js": "^1.16.1", "showdown": "^2.0.3", "typeface-alegreya": "^1.1.13", "typeface-alegreya-sans": "^1.1.13", "waypoints": "^4.0.1", "whatwg-fetch": "^3.6.2" }, "devDependencies": { "@babel/core": "^7.17.5", "@babel/eslint-parser": "^7.17.0", "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-transform-runtime": "^7.17.0", "@babel/preset-env": "^7.16.11", "@cypress/code-coverage": "^3.9.12", "autoprefixer": "^10.4.2", "axios": "^0.26.1", "babel-loader": "^8.2.3", "babel-plugin-istanbul": "^6.1.1", "bootstrap-loader": "^3.0.4", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^10.2.4", "css-loader": "^6.7.1", "cypress": "^9.5.1", "cypress-hmr-restarter": "^2.0.3", "cypress-multi-reporters": "^1.5.0", "ejs": "^3.1.6", "ejs-compiled-loader": "^3.1.0", "eslint": "^8.11.0", "eslint-plugin-chai-friendly": "^0.7.2", "eslint-plugin-cypress": "^2.12.1", "eslint-plugin-import": "^2.25.4", "eslint-plugin-node": "^11.1.0", "eslint-plugin-promise": "^6.0.0", "eslint-plugin-standard": "^5.0.0", "eslint-webpack-plugin": "^3.1.1", "exports-loader": "^3.1.0", "expose-loader": "^3.1.0", "imports-loader": "^3.1.1", "istanbul-lib-coverage": "^3.2.0", "json-stable-stringify": "^1.0.1", "mini-css-extract-plugin": "^2.6.0", "mocha": "^9.2.2", "mocha-junit-reporter": "^2.0.2", "mochawesome": "^7.1.2", "mochawesome-merge": "^4.2.1", "mochawesome-report-generator": "^6.1.1", "node-sass": "^7.0.1", "nodemon": "^2.0.15", "nyc": "^15.1.0", "optimize-css-assets-webpack-plugin": "^6.0.1", "postcss": "^8.4.8", "postcss-loader": "^6.2.1", "postcss-normalize": "^10.0.1", "postcss-reporter": "^7.0.5", "progress-bar-webpack-plugin": "^2.1.0", "resolve-url-loader": "^5.0.0", "robotstxt-webpack-plugin": "^7.0.0", "sass-loader": "^12.6.0", "schema-utils": "^4.0.0", "script-loader": "^0.7.2", "spdx-expression-parse": "^3.0.1", + "sqlite3": "^5.0.2", "style-loader": "^3.3.1", "stylelint": "^14.5.3", "stylelint-config-standard": "^25.0.0", "terser-webpack-plugin": "^5.3.1", "url-loader": "^4.1.1", "webpack": "^5.70.0", "webpack-bundle-tracker": "^1.4.0", "webpack-cli": "^4.9.2", "webpack-dev-server": "^4.7.4", "webpack-log": "^3.0.2", "yaml-loader": "^0.6.0" }, "resolutions": { "jquery": "^3.6.0" }, "browserslist": [ "cover 99.5%", "not dead" ], "nyc": { "report-dir": "cypress/coverage", "exclude": [ "assets/src/bundles/vendors/index.js", "assets/src/thirdparty/**/*.js" ] }, "engines": { "node": ">=12.0.0" } } diff --git a/swh/web/admin/mailmap.py b/swh/web/admin/mailmap.py new file mode 100644 index 00000000..4dc80281 --- /dev/null +++ b/swh/web/admin/mailmap.py @@ -0,0 +1,16 @@ +# Copyright (C) 2022 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +from django.contrib.auth.decorators import permission_required +from django.shortcuts import render + +from swh.web.admin.adminurls import admin_route +from swh.web.auth.utils import MAILMAP_ADMIN_PERMISSION + + +@admin_route(r"mailmap/", view_name="admin-mailmap") +@permission_required(MAILMAP_ADMIN_PERMISSION) +def _admin_mailmap(request): + return render(request, "admin/mailmap.html") diff --git a/swh/web/admin/urls.py b/swh/web/admin/urls.py index 3e3c8f10..57d799ae 100644 --- a/swh/web/admin/urls.py +++ b/swh/web/admin/urls.py @@ -1,28 +1,29 @@ # Copyright (C) 2018-2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from django.conf.urls import url from django.contrib.auth.views import LoginView from django.shortcuts import redirect from swh.web.admin.adminurls import AdminUrls import swh.web.admin.deposit # noqa +import swh.web.admin.mailmap # noqa import swh.web.admin.origin_save # noqa from swh.web.config import is_feature_enabled if is_feature_enabled("add_forge_now"): import swh.web.admin.add_forge_now # noqa def _admin_default_view(request): return redirect("admin-origin-save") urlpatterns = [ url(r"^$", _admin_default_view, name="admin"), url(r"^login/$", LoginView.as_view(template_name="login.html"), name="login"), ] urlpatterns += AdminUrls.get_url_patterns() diff --git a/swh/web/auth/mailmap.py b/swh/web/auth/mailmap.py index f4f628c0..6c651c0f 100644 --- a/swh/web/auth/mailmap.py +++ b/swh/web/auth/mailmap.py @@ -1,119 +1,196 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import json +from typing import Any, Dict from django.conf.urls import url +from django.core.paginator import Paginator from django.db import IntegrityError from django.db.models import Q +from django.http.request import HttpRequest from django.http.response import ( HttpResponse, HttpResponseBadRequest, - HttpResponseForbidden, HttpResponseNotFound, + JsonResponse, ) from rest_framework import serializers from rest_framework.decorators import api_view from rest_framework.request import Request from rest_framework.response import Response from swh.web.auth.models import UserMailmap, UserMailmapEvent -from swh.web.auth.utils import MAILMAP_PERMISSION +from swh.web.auth.utils import ( + MAILMAP_ADMIN_PERMISSION, + MAILMAP_PERMISSION, + any_permission_required, +) class UserMailmapSerializer(serializers.ModelSerializer): class Meta: model = UserMailmap fields = "__all__" @api_view(["GET"]) +@any_permission_required(MAILMAP_PERMISSION, MAILMAP_ADMIN_PERMISSION) def profile_list_mailmap(request: Request) -> HttpResponse: - if not request.user.has_perm(MAILMAP_PERMISSION): - return HttpResponseForbidden() + mailmap_admin = request.user.has_perm(MAILMAP_ADMIN_PERMISSION) - mms = UserMailmap.objects.filter(user_id=str(request.user.id),).all() + mms = UserMailmap.objects.filter( + user_id=None if mailmap_admin else str(request.user.id) + ).all() return Response(UserMailmapSerializer(mms, many=True).data) @api_view(["POST"]) +@any_permission_required(MAILMAP_PERMISSION, MAILMAP_ADMIN_PERMISSION) def profile_add_mailmap(request: Request) -> HttpResponse: - if not request.user.has_perm(MAILMAP_PERMISSION): - return HttpResponseForbidden() + mailmap_admin = request.user.has_perm(MAILMAP_ADMIN_PERMISSION) event = UserMailmapEvent.objects.create( user_id=str(request.user.id), request_type="add", request=json.dumps(request.data), ) from_email = request.data.pop("from_email", None) if not from_email: - return HttpResponseBadRequest("'from_email' must be provided and non-empty.") + return HttpResponseBadRequest( + "'from_email' must be provided and non-empty.", content_type="text/plain" + ) + + user_id = None if mailmap_admin else str(request.user.id) + + from_email_verified = request.data.pop("from_email_verified", False) + if mailmap_admin: + # consider email verified when mailmap is added by admin + from_email_verified = True try: UserMailmap.objects.create( - user_id=str(request.user.id), from_email=from_email, **request.data + user_id=user_id, + from_email=from_email, + from_email_verified=from_email_verified, + **request.data, ) except IntegrityError as e: - if "user_mailmap_from_email_key" in e.args[0]: - return HttpResponseBadRequest("This 'from_email' already exists.") + if ( + "user_mailmap_from_email_key" in e.args[0] + or "user_mailmap.from_email" in e.args[0] + ): + return HttpResponseBadRequest( + "This 'from_email' already exists.", content_type="text/plain" + ) else: raise event.successful = True event.save() - mm = UserMailmap.objects.get(user_id=str(request.user.id), from_email=from_email) + mm = UserMailmap.objects.get(user_id=user_id, from_email=from_email) return Response(UserMailmapSerializer(mm).data) @api_view(["POST"]) +@any_permission_required(MAILMAP_PERMISSION, MAILMAP_ADMIN_PERMISSION) def profile_update_mailmap(request: Request) -> HttpResponse: - if not request.user.has_perm(MAILMAP_PERMISSION): - return HttpResponseForbidden() + mailmap_admin = request.user.has_perm(MAILMAP_ADMIN_PERMISSION) event = UserMailmapEvent.objects.create( user_id=str(request.user.id), request_type="update", request=json.dumps(request.data), ) from_email = request.data.pop("from_email", None) if not from_email: - return HttpResponseBadRequest("'from_email' must be provided and non-empty.") + return HttpResponseBadRequest( + "'from_email' must be provided and non-empty.", content_type="text/plain" + ) - user_id = str(request.user.id) + user_id = None if mailmap_admin else str(request.user.id) try: to_update = ( - UserMailmap.objects.filter(Q(user_id__isnull=True) | Q(user_id=user_id)) + UserMailmap.objects.filter(user_id=user_id) .filter(from_email=from_email) .get() ) except UserMailmap.DoesNotExist: - return HttpResponseNotFound() + return HttpResponseNotFound("'from_email' cannot be found in mailmaps.") for attr, value in request.data.items(): setattr(to_update, attr, value) to_update.save() event.successful = True event.save() mm = UserMailmap.objects.get(user_id=user_id, from_email=from_email) return Response(UserMailmapSerializer(mm).data) +@any_permission_required(MAILMAP_PERMISSION, MAILMAP_ADMIN_PERMISSION) +def profile_list_mailmap_datatables(request: HttpRequest) -> HttpResponse: + mailmap_admin = request.user.has_perm(MAILMAP_ADMIN_PERMISSION) + + mailmaps = UserMailmap.objects.filter( + user_id=None if mailmap_admin else str(request.user.id) + ) + + search_value = request.GET.get("search[value]", "") + + column_order = request.GET.get("order[0][column]") + field_order = request.GET.get(f"columns[{column_order}][name]", "from_email") + order_dir = request.GET.get("order[0][dir]", "asc") + if order_dir == "desc": + field_order = "-" + field_order + + mailmaps = mailmaps.order_by(field_order) + + table_data: Dict[str, Any] = {} + table_data["draw"] = int(request.GET.get("draw", 1)) + table_data["recordsTotal"] = mailmaps.count() + + length = int(request.GET.get("length", 10)) + page = int(request.GET.get("start", 0)) / length + 1 + + if search_value: + mailmaps = mailmaps.filter( + Q(from_email__icontains=search_value) + | Q(display_name__icontains=search_value) + ) + + table_data["recordsFiltered"] = mailmaps.count() + + paginator = Paginator(mailmaps, length) + + mailmaps_data = [ + UserMailmapSerializer(mm).data for mm in paginator.page(int(page)).object_list + ] + + table_data["data"] = mailmaps_data + + return JsonResponse(table_data) + + urlpatterns = [ url(r"^profile/mailmap/list/$", profile_list_mailmap, name="profile-mailmap-list",), url(r"^profile/mailmap/add/$", profile_add_mailmap, name="profile-mailmap-add",), url( r"^profile/mailmap/update/$", profile_update_mailmap, name="profile-mailmap-update", ), + url( + r"^profile/mailmap/list/datatables/$", + profile_list_mailmap_datatables, + name="profile-mailmap-list-datatables", + ), ] diff --git a/swh/web/auth/migrations/0006_fix_mailmap_admin_user_id.py b/swh/web/auth/migrations/0006_fix_mailmap_admin_user_id.py new file mode 100644 index 00000000..f645b362 --- /dev/null +++ b/swh/web/auth/migrations/0006_fix_mailmap_admin_user_id.py @@ -0,0 +1,41 @@ +# Copyright (C) 2022 The Software Heritage developers +# See the AUTHORS file at the top-level directory of this distribution +# License: GNU Affero General Public License version 3, or any later version +# See top-level LICENSE file for more information + +import datetime + +from django.db import migrations + + +def _set_first_mailmaps_as_edited_by_admin(apps, schema_editor): + """First mailmaps in production database have been created by a user + with "swh.web.mailmap" permission because no "swh.web.admin.mailmap" + permission existed at the time. + + So change user_id to None to indicate these mailmaps have been created + by a mailmap administrator. + """ + UserMailmap = apps.get_model("swh_web_auth", "UserMailmap") + + for mailmap in UserMailmap.objects.filter( + last_update_date__lte=datetime.datetime( + 2022, 2, 12 + ) # first mailmaps added on 2022/2/11 in production + ): + if mailmap.user_id is not None: + mailmap.user_id = None + mailmap.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("swh_web_auth", "0005_usermailmapevent"), + ] + + operations = [ + migrations.RunPython( + _set_first_mailmaps_as_edited_by_admin, migrations.RunPython.noop + ), + ] diff --git a/swh/web/auth/utils.py b/swh/web/auth/utils.py index c6ddba96..c93708d0 100644 --- a/swh/web/auth/utils.py +++ b/swh/web/auth/utils.py @@ -1,98 +1,115 @@ # Copyright (C) 2020-2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from base64 import urlsafe_b64encode from typing import List from cryptography.fernet import Fernet from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from django.contrib.auth.decorators import user_passes_test from django.http.request import HttpRequest +from swh.web.common.exc import ForbiddenExc + OIDC_SWH_WEB_CLIENT_ID = "swh-web" SWH_AMBASSADOR_PERMISSION = "swh.ambassador" API_SAVE_ORIGIN_PERMISSION = "swh.web.api.save_origin" ADMIN_LIST_DEPOSIT_PERMISSION = "swh.web.admin.list_deposits" MAILMAP_PERMISSION = "swh.web.mailmap" ADD_FORGE_MODERATOR_PERMISSION = "swh.web.add_forge_now.moderator" +MAILMAP_ADMIN_PERMISSION = "swh.web.admin.mailmap" def _get_fernet(password: bytes, salt: bytes) -> Fernet: """ Instantiate a Fernet system from a password and a salt value (see https://cryptography.io/en/latest/fernet/). Args: password: user password that will be used to generate a Fernet key derivation function salt: value that will be used to generate a Fernet key derivation function Returns: The Fernet system """ kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=salt, iterations=100000, backend=default_backend(), ) key = urlsafe_b64encode(kdf.derive(password)) return Fernet(key) def encrypt_data(data: bytes, password: bytes, salt: bytes) -> bytes: """ Encrypt data using Fernet system (symmetric encryption). Args: data: input data to encrypt password: user password that will be used to generate a Fernet key derivation function salt: value that will be used to generate a Fernet key derivation function Returns: The encrypted data """ return _get_fernet(password, salt).encrypt(data) def decrypt_data(data: bytes, password: bytes, salt: bytes) -> bytes: """ Decrypt data using Fernet system (symmetric encryption). Args: data: input data to decrypt password: user password that will be used to generate a Fernet key derivation function salt: value that will be used to generate a Fernet key derivation function Returns: The decrypted data """ return _get_fernet(password, salt).decrypt(data) def privileged_user(request: HttpRequest, permissions: List[str] = []) -> bool: """Determine whether a user is authenticated and is a privileged one (e.g ambassador). This allows such user to have access to some more actions (e.g. bypass save code now review, access to 'archives' type...). A user is considered as privileged if he is a staff member or has any permission from those provided as parameters. Args: request: Input django HTTP request permissions: list of permission names to determine if user is privileged or not Returns: Whether the user is privileged or not. """ user = request.user return user.is_authenticated and ( user.is_staff or any([user.has_perm(perm) for perm in permissions]) ) + + +def any_permission_required(*perms): + """View decorator granting access to it if user has at least one + permission among those passed as parameters. + """ + + def check_perms(user): + if any(user.has_perm(perm) for perm in perms): + return True + raise ForbiddenExc + + return user_passes_test(check_perms) diff --git a/swh/web/common/utils.py b/swh/web/common/utils.py index d055662d..b79c7868 100644 --- a/swh/web/common/utils.py +++ b/swh/web/common/utils.py @@ -1,533 +1,535 @@ # Copyright (C) 2017-2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from datetime import datetime, timezone import os import re from typing import Any, Dict, List, Optional import urllib.parse from xml.etree import ElementTree from bs4 import BeautifulSoup from docutils.core import publish_parts import docutils.parsers.rst import docutils.utils from docutils.writers.html5_polyglot import HTMLTranslator, Writer from iso8601 import ParseError, parse_date from pkg_resources import get_distribution from prometheus_client.registry import CollectorRegistry import requests from requests.auth import HTTPBasicAuth from django.core.cache import cache from django.http import HttpRequest, QueryDict from django.shortcuts import redirect from django.urls import resolve from django.urls import reverse as django_reverse from swh.web.auth.utils import ( ADD_FORGE_MODERATOR_PERMISSION, ADMIN_LIST_DEPOSIT_PERMISSION, + MAILMAP_ADMIN_PERMISSION, ) from swh.web.common.exc import BadInputExc from swh.web.common.typing import QueryParameters from swh.web.config import SWH_WEB_SERVER_NAME, get_config, search SWH_WEB_METRICS_REGISTRY = CollectorRegistry(auto_describe=True) swh_object_icons = { "alias": "mdi mdi-star", "branch": "mdi mdi-source-branch", "branches": "mdi mdi-source-branch", "content": "mdi mdi-file-document", "cnt": "mdi mdi-file-document", "directory": "mdi mdi-folder", "dir": "mdi mdi-folder", "origin": "mdi mdi-source-repository", "ori": "mdi mdi-source-repository", "person": "mdi mdi-account", "revisions history": "mdi mdi-history", "release": "mdi mdi-tag", "rel": "mdi mdi-tag", "releases": "mdi mdi-tag", "revision": "mdi mdi-rotate-90 mdi-source-commit", "rev": "mdi mdi-rotate-90 mdi-source-commit", "snapshot": "mdi mdi-camera", "snp": "mdi mdi-camera", "visits": "mdi mdi-calendar-month", } def reverse( viewname: str, url_args: Optional[Dict[str, Any]] = None, query_params: Optional[QueryParameters] = None, current_app: Optional[str] = None, urlconf: Optional[str] = None, request: Optional[HttpRequest] = None, ) -> str: """An override of django reverse function supporting query parameters. Args: viewname: the name of the django view from which to compute a url url_args: dictionary of url arguments indexed by their names query_params: dictionary of query parameters to append to the reversed url current_app: the name of the django app tighten to the view urlconf: url configuration module request: build an absolute URI if provided Returns: str: the url of the requested view with processed arguments and query parameters """ if url_args: url_args = {k: v for k, v in url_args.items() if v is not None} url = django_reverse( viewname, urlconf=urlconf, kwargs=url_args, current_app=current_app ) if query_params: query_params = {k: v for k, v in query_params.items() if v is not None} if query_params and len(query_params) > 0: query_dict = QueryDict("", mutable=True) for k in sorted(query_params.keys()): query_dict[k] = query_params[k] url += "?" + query_dict.urlencode(safe="/;:") if request is not None: url = request.build_absolute_uri(url) return url def datetime_to_utc(date): """Returns datetime in UTC without timezone info Args: date (datetime.datetime): input datetime with timezone info Returns: datetime.datetime: datetime in UTC without timezone info """ if date.tzinfo and date.tzinfo != timezone.utc: return date.astimezone(tz=timezone.utc) else: return date def parse_iso8601_date_to_utc(iso_date: str) -> datetime: """Given an ISO 8601 datetime string, parse the result as UTC datetime. Returns: a timezone-aware datetime representing the parsed date Raises: swh.web.common.exc.BadInputExc: provided date does not respect ISO 8601 format Samples: - 2016-01-12 - 2016-01-12T09:19:12+0100 - 2007-01-14T20:34:22Z """ try: date = parse_date(iso_date) return datetime_to_utc(date) except ParseError as e: raise BadInputExc(e) def shorten_path(path): """Shorten the given path: for each hash present, only return the first 8 characters followed by an ellipsis""" sha256_re = r"([0-9a-f]{8})[0-9a-z]{56}" sha1_re = r"([0-9a-f]{8})[0-9a-f]{32}" ret = re.sub(sha256_re, r"\1...", path) return re.sub(sha1_re, r"\1...", ret) def format_utc_iso_date(iso_date, fmt="%d %B %Y, %H:%M UTC"): """Turns a string representation of an ISO 8601 datetime string to UTC and format it into a more human readable one. For instance, from the following input string: '2017-05-04T13:27:13+02:00' the following one is returned: '04 May 2017, 11:27 UTC'. Custom format string may also be provided as parameter Args: iso_date (str): a string representation of an ISO 8601 date fmt (str): optional date formatting string Returns: str: a formatted string representation of the input iso date """ if not iso_date: return iso_date date = parse_iso8601_date_to_utc(iso_date) return date.strftime(fmt) def gen_path_info(path): """Function to generate path data navigation for use with a breadcrumb in the swh web ui. For instance, from a path /folder1/folder2/folder3, it returns the following list:: [{'name': 'folder1', 'path': 'folder1'}, {'name': 'folder2', 'path': 'folder1/folder2'}, {'name': 'folder3', 'path': 'folder1/folder2/folder3'}] Args: path: a filesystem path Returns: list: a list of path data for navigation as illustrated above. """ path_info = [] if path: sub_paths = path.strip("/").split("/") path_from_root = "" for p in sub_paths: path_from_root += "/" + p path_info.append({"name": p, "path": path_from_root.strip("/")}) return path_info def parse_rst(text, report_level=2): """ Parse a reStructuredText string with docutils. Args: text (str): string with reStructuredText markups in it report_level (int): level of docutils report messages to print (1 info 2 warning 3 error 4 severe 5 none) Returns: docutils.nodes.document: a parsed docutils document """ parser = docutils.parsers.rst.Parser() components = (docutils.parsers.rst.Parser,) settings = docutils.frontend.OptionParser( components=components ).get_default_values() settings.report_level = report_level document = docutils.utils.new_document("rst-doc", settings=settings) parser.parse(text, document) return document def get_client_ip(request): """ Return the client IP address from an incoming HTTP request. Args: request (django.http.HttpRequest): the incoming HTTP request Returns: str: The client IP address """ x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") if x_forwarded_for: ip = x_forwarded_for.split(",")[0] else: ip = request.META.get("REMOTE_ADDR") return ip def is_swh_web_development(request: HttpRequest) -> bool: """Indicate if we are running a development version of swh-web. """ site_base_url = request.build_absolute_uri("/") return any( host in site_base_url for host in ("localhost", "127.0.0.1", "testserver") ) def is_swh_web_staging(request: HttpRequest) -> bool: """Indicate if we are running a staging version of swh-web. """ config = get_config() site_base_url = request.build_absolute_uri("/") return any( server_name in site_base_url for server_name in config["staging_server_names"] ) def is_swh_web_production(request: HttpRequest) -> bool: """Indicate if we are running the public production version of swh-web. """ return SWH_WEB_SERVER_NAME in request.build_absolute_uri("/") browsers_supported_image_mimes = set( [ "image/gif", "image/png", "image/jpeg", "image/bmp", "image/webp", "image/svg", "image/svg+xml", ] ) def context_processor(request): """ Django context processor used to inject variables in all swh-web templates. """ config = get_config() if ( hasattr(request, "user") and request.user.is_authenticated and not hasattr(request.user, "backend") ): # To avoid django.template.base.VariableDoesNotExist errors # when rendering templates when standard Django user is logged in. request.user.backend = "django.contrib.auth.backends.ModelBackend" return { "swh_object_icons": swh_object_icons, "available_languages": None, "swh_client_config": config["client_config"], "oidc_enabled": bool(config["keycloak"]["server_url"]), "browsers_supported_image_mimes": browsers_supported_image_mimes, "keycloak": config["keycloak"], "site_base_url": request.build_absolute_uri("/"), "DJANGO_SETTINGS_MODULE": os.environ["DJANGO_SETTINGS_MODULE"], "status": config["status"], "swh_web_dev": is_swh_web_development(request), "swh_web_staging": is_swh_web_staging(request), "swh_web_version": get_distribution("swh.web").version, "iframe_mode": False, "ADMIN_LIST_DEPOSIT_PERMISSION": ADMIN_LIST_DEPOSIT_PERMISSION, "ADD_FORGE_MODERATOR_PERMISSION": ADD_FORGE_MODERATOR_PERMISSION, "FEATURES": get_config()["features"], + "MAILMAP_ADMIN_PERMISSION": MAILMAP_ADMIN_PERMISSION, } def resolve_branch_alias( snapshot: Dict[str, Any], branch: Optional[Dict[str, Any]] ) -> Optional[Dict[str, Any]]: """ Resolve branch alias in snapshot content. Args: snapshot: a full snapshot content branch: a branch alias contained in the snapshot Returns: The real snapshot branch that got aliased. """ while branch and branch["target_type"] == "alias": if branch["target"] in snapshot["branches"]: branch = snapshot["branches"][branch["target"]] else: from swh.web.common import archive snp = archive.lookup_snapshot( snapshot["id"], branches_from=branch["target"], branches_count=1 ) if snp and branch["target"] in snp["branches"]: branch = snp["branches"][branch["target"]] else: branch = None return branch class _NoHeaderHTMLTranslator(HTMLTranslator): """ Docutils translator subclass to customize the generation of HTML from reST-formatted docstrings """ def __init__(self, document): super().__init__(document) self.body_prefix = [] self.body_suffix = [] _HTML_WRITER = Writer() _HTML_WRITER.translator_class = _NoHeaderHTMLTranslator def rst_to_html(rst: str) -> str: """ Convert reStructuredText document into HTML. Args: rst: A string containing a reStructuredText document Returns: Body content of the produced HTML conversion. """ settings = { "initial_header_level": 2, "halt_level": 4, "traceback": True, "file_insertion_enabled": False, "raw_enabled": False, } pp = publish_parts(rst, writer=_HTML_WRITER, settings_overrides=settings) return f'+ This interface enables to manage author display names in the archive based + on their emails. +
+Verified | +Display name | +Activated | +Last update | +Effective | ++ |
---|